---
title: 可见性、有序性和原子性问题
description: 并发BUG的源头
---


CPU、内存、I/O设备之间一直存在一个核心矛盾，速度差异。

程序里的大部分语句都需要访问内存，有的还要访问 I/O ，根据木桶理论，程序的性能取决于最慢的操作————读写 I/O 设备，也就是说单方面提高 CPU 的性能是无效的。

为了合理利用 CPU 的高性能，平衡三者之间的速度差异，计算机体系结构、操作系统、编译程序都做出了贡献，主要体现为：
1. CPU 增加了缓存，以均衡与内存之间的速度差异；
2. 操作系统增加了进程、线程，以分时复用 CPU ，进而均衡 CPU 与 I/O 设备之间的速度差异。
3. 编译程序优化指令执行次序，使得缓存能够得到更加合理的利用。

缓存、线程、编译优化虽然提高了程序性能，但也带来了新的问题，并发程序很多诡异问题的根源也在这里。

缓存导致了可见性问题，线程切换带来了原子性问题，编译优化带来了有序性问题。

## 缓存导致的可见性问题
在单核CPU上，所有的线程都在一颗 CPU 上执行，CPU 缓存与内存之间的数据一致性很容易解决。因为所有线程都是操作同一个 CPU 的缓存，一个线程对缓存的写，对另一个线程来说一定是可见的。

![](https://img.wkq.pub/pic/a07e8182819e2b260ce85b2167d446da.webp)

一个线程对共享变量的修改，另外一个线程能够立即看到，我们称之为**可见性**。

在多核 CPU 上，每颗 CPU 都有自己的缓存，这时缓存与内存之间的数据一致性就很难解决了，当多个线程在不同的 CPU 上执行时，这些线程操作的是不同的 CPU 缓存。

![](https://img.wkq.pub/pic/e2aa76928b2bc135e08e7590ca36e0ea.webp)

```java title='验证可见性问题'
public class Test {
    private static long count = 0;
    private static void add10K() {
        int idx = 0;
        while (idx++ < 10000) {
            count += 1;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //创建两个线程，执行 add10k 操作
        Thread thread1 = new Thread((Test::add10K));
        Thread thread2 = new Thread((Test::add10K));
        thread1.start();
        thread2.start();
        thread2.join();
        thread1.join();
        System.out.println("count = " + count);
    }
}
```

假设 thread1 和 thread2 同时开始执行，那么第一次都会将 count = 0 读到各自的 CPU 缓存中，执行完 count+=1 之后，各自 CPU 缓存里的值都是 1，同时写入内存后，我们发现内存中是 1 ，而不是期望的 2。之后由于各自的 CPU 缓存里都有了 count 值，两个线程都是基于 CPU 缓存里的 count 值来计算，所以导致最终的 count 值都是小于 20000的。这就是缓存的可见性问题。

## 线程切换带来的原子性问题

由于IO太慢，早期操作系统发明了进程，即使在单核 CPU 上也可以一边听课，一边写代码。

操作系统允许进程执行一小段时间，例如 50 毫秒，过了 50 毫秒操作系统就会重新选择一个进程来执行（我们称之为任务切换），这个 50 毫秒称为时间片。

![254b129b145d80e9bb74123d6e620efb](https://img.wkq.pub/pic/254b129b145d80e9bb74123d6e620efb.webp)

在一个时间片内，如果一个进程进行一个 IO 操作，例如读取文件，这个时候该进程可以把自己标记为 “休眠状态”，并让出 CPU 的使用权，待文件读进内存，操作系统会把这个休眠进程唤醒，唤醒后的进程就有机会重新获得 CPU 的使用权了。

这里的进程在等待 IO 时之所以会释放 CPU 使用权，是为了让 CPU 在这段等待时间里可以做别的事情，这样一来 CPU 的使用率就上升了；此外，如果这时有另一个进程也读文件，读文件的操作就会排队，磁盘驱动在完成一个进程的读操作之后，发现有排队的任务，就会立即启动下一个读操作，这样 IO 的使用了也就上来了。



早期的操作系统基于进程来调度 CPU，不同进程间是不共享内存的，所以进程要做任务切换就要切换内存映射地址，而一个进程创建的所有线程，都是共享一个内存空间的，所以线程做任务切换的成本很低。

Java 并发程序都是基于多线程的，自然也会涉及到任务切换，任务切换的时机大多是时间片结束的时候。高级语言里一条语句往往需要多条 CPU 指令完成，例如上面的 count+=1;至少需要三条 CPU 指令。

* 首先，需要把变量 count 从内存加载到 CPU 的寄存器；
* 之后，在寄存器执行 +1 操作；
* 最后，将结果写入内存（缓存机制导致可能写入的是缓存而不是内存）

操作系统做任务切换，可以发生在任何一条 CPU 指令执行完，

![](https://img.wkq.pub/pic/33777c468872cb9a99b3cdc1ff597063.webp)

我们把一个或多个操作在CPU 执行的过程中不被中断的特性称为原子性。

CPU 能保证的原子操作是指令级别的

## 编译优化带来的有序性问题

有序性是指程序按代码的先后顺序执行，编译器为了优化性能，有时会改变程序中语句的先后顺序，但不影响程序的最终结果。但有时候编译优化可能导致意想不到的 BUG。

一个经典的案例就是双重检查锁
```java
public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
```
上述代码有可能引发空指针异常，问题出在 new 操作上，我们以为的 new 操作是：
1. 分配一块内存；
2. 在内存 M 上初始化 Singleton 对象；
3. 然后 M 的地址赋值给 instance 变量；

但是实际上优化后的执行路径可能是这样的：
1. 分配一块内存；
2. 将 M 的地址赋值给 instance 变量；
3. 最后在内存 M 上初始化 Singleton 对象；

假设线程 A 执行完指令 2  后发生了线程切换，切换到线程 B 上，此时线程 B 也执行 getInstance() 方法，那么线程 B 在执行第一个判断的时候会发现 instance != null,所以直接返回 instance。而此时的 instance 是没有初始化过的。这时候访问 instance 的成员就可以导致空指针异常。

![](https://img.wkq.pub/pic/64c955c65010aae3902ec918412827d8.webp)
